Node.js Design Patterns

读书目标

打牢 Node.js 底层心智模型,为 Electron 主进程开发提供理论基础。
前四章 = 平台原理 → 模块系统 → 异步基础 → 回调控制流,是全书的地基。


Chapter 1 — The Node.js Platform

1.1 Node.js 哲学(The Node.js Philosophy)

与 Electron 的关联

Electron 主进程 = Node.js 环境,这套哲学决定了为什么你不应该在主进程里堆砌逻辑,而是通过 IPC 把职责分散到各模块。

1.2 Node.js 工作原理

I/O 的本质问题

I/O 类型 延迟量级
内存访问 纳秒
磁盘读写 毫秒
网络请求 数十~数百毫秒

传统服务器(Apache):每请求一个线程 → 大量线程阻塞等 I/O → 资源浪费

非阻塞 I/O & 事件多路复用(Event Demultiplexing)

┌──────────────────────────────────────────────────┐
│  应用层发起多个 I/O 请求(非阻塞)                  │
│      ↓                                            │
│  交给操作系统 I/O 多路复用器(epoll/kqueue/IOCP)  │
│      ↓(等待事件就绪)                             │
│  就绪事件放入 Event Queue                          │
│      ↓                                            │
│  Event Loop 逐一取出并执行回调                      │
└──────────────────────────────────────────────────┘

Reactor 模式

  1. 应用程序提交 I/O 操作,同时提供 handler(回调)
  2. libuv 把请求传给操作系统
  3. I/O 完成后,事件放入队列
  4. Event Loop 调用对应 handler
  5. handler 执行完毕,控制权归还 Event Loop
关键结论

Node.js 是单线程事件循环,但 I/O 是由 libuv 的线程池(默认 4 线程)在后台处理的。「单线程」只指 JavaScript 执行环境。

1.3 Node.js 技术栈

JavaScript 代码
    ↓
V8(JavaScript 引擎)
    ↓
Node.js Bindings(C++ 层)
    ↓
libuv(跨平台异步 I/O)+ 核心 C 库
    ↓
操作系统

Chapter 2 — The Module System

2.1 为什么需要模块

2.2 CommonJS(CJS)

基本用法

// math.js — 导出
function add(a, b) { return a + b }
module.exports = { add }

// main.js — 导入
const { add } = require('./math')

解析算法(Resolving Algorithm)

require('X') 的查找顺序:

  1. 核心模块(fspath 等)→ 直接返回
  2. ./../ 开头 → 文件路径解析
    • 尝试 XX.jsX.jsonX.node
    • 尝试 X/index.js
  3. 否则 → 逐级向上查找 node_modules/X

模块缓存(Module Cache)

require('./foo') === require('./foo') // true — 同一对象引用

循环依赖(Circular Dependencies)

不会报错——Node.js 用「部分导出快照」打破循环,静默返回对方当前已有的 exports

1. 开始加载 a.js,注册空 exports {}
2. a.js require('./b') → 开始加载 b.js
3. b.js require('./a') → 检测到循环,返回当前 a 的 exports:{}
4. b.js 执行完毕,exports = { name: 'b' }
5. 回到 a.js,b = { name: 'b' },继续执行
6. a.js 执行完毕,exports = { name: 'a' }
// a.js
const b = require('./b')
console.log('a 拿到 b.name:', b.name)  // 'b' ← 正常
exports.name = 'a'

// b.js
const a = require('./a')
console.log('b 拿到 a.name:', a.name)  // undefined ← 此时 a 还没 export
exports.name = 'b'
陷阱

循环依赖时,被 require 的模块拿到的是对方「还没导出完」的版本。不报错只报 undefined——这是最难查的 bug 之一。

ESM 的循环依赖

ESM 有静态链接阶段,导出的是活绑定(live binding)而非值拷贝。若被引用的变量在使用前已初始化则正常,否则仍是 undefined。同样不报错,行为更难预测。

如何发现循环依赖

工具检测(推荐):

# madge — 可视化依赖图,专门查循环
npx madge --circular src/index.js

# 输出示例:
# Found 2 circular dependencies!
# 1) src/a.js -> src/b.js -> src/a.js

# 生成 SVG 依赖图
npx madge --image graph.svg src/index.js

# dpdm — 支持 TypeScript,速度更快
npx dpdm --no-warning --no-tree src/index.ts

ESLint 实时提示:

{ "rules": { "import/no-cycle": ["error", { "maxDepth": 10 }] } }

运行时快速排查——看到莫名 undefined 时:

const a = require('./a')
console.log('[debug] a loaded:', Object.keys(a))  // 如果是 {} 说明循环了

根治方法

方案 适用场景
提取公共模块 A 和 B 互相依赖 → 把共享部分移到 C
依赖注入 A 不直接 require B,而是接收 B 作为参数
懒加载 在函数内部 require,而非模块顶层
重新设计分层 根本原因是两个模块职责边界不清

2.3 ES Modules(ESM)

// 命名导出
export function add(a, b) { return a + b }
export const PI = 3.14

// 默认导出
export default class Calculator { ... }

// 导入
import { add, PI } from './math.js'
import Calculator from './calculator.js'

// 动态导入(异步)
const { add } = await import('./math.js')

ESM vs CJS 关键差异

CJS ESM
加载时机 运行时(动态) 静态分析(编译时)
this 顶层 module.exports undefined
__dirname / __filename ❌(用 import.meta.url
Tree-shaking
互操作 CJS 可 require CJS ESM 可 import CJS(反之有限制)

2.4 常用模块模式

// 1. 命名导出 — 多个工具函数
export const add = (a, b) => a + b
export const sub = (a, b) => a - b

// 2. 导出类 — 需要多实例
export class Database { constructor(url) {...} }

// 3. 导出实例(单例)— 共享状态
class Logger { log(msg) {...} }
export default new Logger()  // 整个进程共一个

// 4. Monkey Patching(慎用)
import fs from 'fs'
const originalReadFile = fs.readFile
fs.readFile = (...args) => { /* 包装逻辑 */ originalReadFile(...args) }

Chapter 3 — Callbacks and Events

3.1 Callback 模式

延续传递风格(Continuation-Passing Style,CPS)

// 直接风格(Direct Style)
function add(a, b) { return a + b }

// CPS 风格 — 结果通过回调传递
function addCPS(a, b, callback) { callback(a + b) }

// 异步 CPS
function readFileCPS(path, callback) {
  fs.readFile(path, (err, data) => callback(err, data))
}

Node.js 错误优先约定(Error-First Callback)

fs.readFile('file.txt', (err, data) => {
  if (err) {
    // 处理错误,return 避免继续执行
    return console.error(err)
  }
  console.log(data)
})
三条铁律

  1. callback 的第一个参数永远是 err(无错误则为 null
  2. 回调要么同步执行,要么永远异步执行——不能混用
  3. 不能在回调内部抛出异常(throw 会逃逸 Event Loop,导致进程崩溃)

未捕获异常

process.on('uncaughtException', (err) => {
  console.error('Uncaught:', err)
  process.exit(1) // 必须退出,状态已不可预测
})

3.2 Observer 模式 — EventEmitter

import { EventEmitter } from 'events'

class MyEmitter extends EventEmitter {}
const emitter = new MyEmitter()

// 注册监听
emitter.on('data', (chunk) => console.log('收到:', chunk))
emitter.once('end', () => console.log('结束'))  // 只触发一次

// 触发事件
emitter.emit('data', 'hello')
emitter.emit('end')

错误处理

// 必须监听 error 事件,否则会抛出异常
emitter.on('error', (err) => console.error('错误:', err))

同步 vs 异步事件

// ❌ 危险:同步 emit 在 on() 注册前触发
const emitter = new EventEmitter()
emitter.emit('ready')        // 此时还没有监听器
emitter.on('ready', () => console.log('ready'))  // 永远不会触发

// ✅ 正确:用 process.nextTick 推迟到下一个 tick
class MyStream extends EventEmitter {
  constructor() {
    super()
    process.nextTick(() => this.emit('ready'))  // 确保监听器已注册
  }
}

Callback vs EventEmitter 选择指南

场景 选择
结果只有一次(成功/失败) Callback / Promise
事件可能触发多次 EventEmitter
多个监听者 EventEmitter
需要解耦生产者与消费者 EventEmitter

Chapter 4 — Asynchronous Control Flow with Callbacks

4.1 Callback Hell(回调地狱)

// ❌ 典型的回调地狱 — 难以阅读、难以维护
fs.readFile('a.txt', (err, a) => {
  if (err) return handleErr(err)
  fs.readFile('b.txt', (err, b) => {
    if (err) return handleErr(err)
    fs.writeFile('out.txt', a + b, (err) => {
      if (err) return handleErr(err)
      console.log('done')
    })
  })
})

4.2 最佳实践(逃离回调地狱)

  1. 提前返回if (err) return callback(err) — 减少嵌套
  2. 命名函数:把匿名回调抽成有名字的函数
  3. 模块化:拆分成小函数,每个只做一件事
  4. 使用 async/await(现代 Node.js 首选,见第5章)
// ✅ 拆分命名函数
function onWriteDone(err) {
  if (err) return handleErr(err)
  console.log('done')
}
function onReadB(err, a, b) {
  if (err) return handleErr(err)
  fs.writeFile('out.txt', a + b, onWriteDone)
}
function onReadA(err, a) {
  if (err) return handleErr(err)
  fs.readFile('b.txt', (err, b) => onReadB(err, a, b))
}
fs.readFile('a.txt', onReadA)

4.3 顺序执行(Sequential Execution)

任务一个接一个,前一个完成才开始下一个:

function executeSequentially(tasks, callback) {
  function iterate(index) {
    if (index === tasks.length) return callback()
    tasks[index](() => iterate(index + 1))
  }
  iterate(0)
}

适用场景:任务间有依赖关系,或需要控制执行顺序。

4.4 顺序迭代(Sequential Iteration)

对集合中每个元素依次异步处理:

function processItems(items, asyncTask, callback) {
  function next(index) {
    if (index === items.length) return callback()
    asyncTask(items[index], (err) => {
      if (err) return callback(err)
      next(index + 1)
    })
  }
  next(0)
}

4.5 并行执行(Parallel Execution)

所有任务同时启动,全部完成后回调:

function executeParallel(tasks, callback) {
  let completed = 0
  let hasErrors = false

  tasks.forEach(task => {
    task((err) => {
      if (hasErrors) return
      if (err) { hasErrors = true; return callback(err) }
      if (++completed === tasks.length) callback()
    })
  })
}
竞态条件(Race Condition)

并行任务共享变量时要注意写冲突。Node.js 单线程不存在真正的并发写,但异步交错仍可导致逻辑错误。

适用场景:任务间无依赖,追求最短完成时间。

4.6 有限并行(Limited Parallel Execution)

防止同时发起过多请求(如文件句柄、HTTP 连接耗尽):

function limitedParallel(tasks, concurrency, callback) {
  let running = 0, index = 0, completed = 0

  function next() {
    while (running < concurrency && index < tasks.length) {
      const task = tasks[index++]
      running++
      task(() => {
        running--
        completed++
        if (completed === tasks.length) return callback()
        next()
      })
    }
  }
  next()
}
实用建议

生产代码中直接用 p-limit(npm)或 Promise.all + 信号量,不需要手写。


四章总结 & 知识地图

平台原理 (Ch.1)
  └─ 单线程事件循环 + libuv 线程池
      └─ Reactor 模式:所有异步 I/O 的根基

模块系统 (Ch.2)
  ├─ CJS:运行时加载、缓存单例、循环依赖陷阱
  └─ ESM:静态分析、tree-shaking、动态 import

异步原语 (Ch.3)
  ├─ Callback + 错误优先约定
  └─ EventEmitter + Observer 模式

控制流 (Ch.4)
  ├─ 顺序:任务串联,保序
  ├─ 并行:全部并发,最快完成
  └─ 限流并行:并发上限,保护资源
下一步

Ch.5 开始进入 Promise & async/await,Ch.6 是 Stream——这两章和前四章同等重要,建议连续阅读。


相关笔记